package org.intelligentsia.keystone.boot;

import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.URLDecoder;
import java.security.CodeSource;
import java.security.ProtectionDomain;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipException;

/**
 * 
 * DONT FORGET: LIMIT THIS CLASS TO BOOTSTRAP AND NOTHING MORE!!
 * According on this first sentence, i have added a system for extracting inner resources, and option for updating and
 * restaring system...
 * 
 * BASIC Structure:
 * ${home}/lib all lib contained in single jar file
 * ${home}/plugins all lib which could be added by external way (manual, inner jar, ....)
 * ${home}/tools tools contains utilities like "updater",
 * this folder offer a simply way to update all the system .
 * 
 * option that you could set on command line with a "--name=value" or in a properties files:
 * - Main-Class=java main class
 * 
 * - BootStrap.haltOnError=true|false (default true) if true halt on first error, neither continue as he can
 * 
 * - BootStrap.explode=true|false (default true) if true explode inner jar in lib, tools, and plugins directory
 * according jar inner resources
 * - BootStrap.cleanUpTools=true|false (default false) clean up local 'tools' file system on startup
 * - BootStrap.cleanUpLib=true|false (default true) clean up local 'lib' file system on startup
 * - BootStrap.cleanUpPlugins=true|false (default false) clean up local 'plugins' file system on startup
 * 
 * - BootStrap.verbose=true|false (default false) activate 'verbose' mode
 * - BootStrap.info=true|false (default true) activate 'info' mode
 * - BootStrap.logFile=log file of bootstrap (default is none)
 * 
 * - BootStrap.includeJavaHomeLib=true|false (default false) incluse java home librarie
 * - BootStrap.includeSystemClassLoader=true|false (default false) include system class loader
 * - BootStrap.includePlugins=true|false (default true) include plugins folder in classpath
 * 
 * - BootStrap.restartAfterUpdate=true|false (default false) perform a restart after update
 * 
 * Configuration priority (hight to less):
 * - from command line with --....
 * - from file:keystone.properties
 * - from classpath:keystone.properties
 * - from classpath:META-INF/keystone.properties
 * 
 * BootStrap set two system properties:
 * - BootStrap.location=full path of the booted jar
 * - BootStrap.home= home directory
 * 
 * 
 * Loggin way: http://blogs.sun.com/nickstephen/entry/java_redirecting_system_out_and
 * 
 * @author <a href="mailto:jguibert@intelligents-ia.com" >Jerome Guibert</a>
 */
public final class BootStrap {

	private static Boolean	haltOnError	= Boolean.TRUE;

	/**
	 * Main methods.
	 * 
	 * @param args
	 * @throws IOException
	 *             if something is wrong when reading properties files.
	 */
	public static void main(final String[] args) throws IOException {
		// load startup properties
		final Map<String, String> arguments = Command.loadArguments(args, "keystone.properties");
		// initialize data member according arguments
		BootStrap.initializeConsole(arguments);
		Console.VERBOSE("Arguments " + arguments);
		Extract.initialize(arguments);
		BootStrap.haltOnError = Command.getBooleanArgument(arguments, "BootStrap.haltOnError", Boolean.TRUE);
		// load main class
		final String mainClassName = Command.getStringArgument(arguments, Attributes.Name.MAIN_CLASS.toString(), null);
		// code location and Home
		final String location = BootStrap.getCodeSourceLocation();
		final File home = location != null ? new File(location).getParentFile() : null;
		if (home == null) {
			Console.WARNING("Cannot Find Home location");
			if (BootStrap.haltOnError) {
				return;
			}
		}
		// restartAfterUpdate ?
		if (Command.getBooleanArgument(arguments, "BootStrap.restartAfterUpdate", Boolean.FALSE)) {
			Restarter.restartAfterUpdate(arguments);
			return;
		}
		// check main class
		if (mainClassName == null) {
			Console.WARNING("No Main-Class Found");
			if (BootStrap.haltOnError) {
				return;
			}
		}
		Console.INFO("Main-Class=" + mainClassName);
		// explode inner jar
		if (Command.getBooleanArgument(arguments, "BootStrap.explode", Boolean.TRUE)) {
			if (!Extract.explode(location, home)) {
				return;
			}
		}
		// computing classPath
		List<URL> urls = null;
		try {
			urls = BootStrap.computeClassPath(home, arguments);
		} catch (final IllegalStateException ise) {
			return;
		}
		// Instanciate classloader
		final ClassLoader classloader = new URLClassLoader(
				(URL[]) urls.toArray(new URL[urls.size()]),
				Command.getBooleanArgument(arguments, "BootStrap.includeSystemClassLoader", Boolean.FALSE) ? ClassLoader
						.getSystemClassLoader() : ClassLoader.getSystemClassLoader().getParent());
		// well-behaved Java packages work relative to the context classloader. Others don't (like commons-logging)
		// Set the context classloader in case any classloaders delegate to it. Otherwise it would default to the
		// sun.misc.Launcher$AppClassLoader which
		// is used to launch the jar application, and attempts to load through it would fail if that code is
		// encapsulated inside the one-jar.
		Thread.currentThread().setContextClassLoader(classloader);
		// Set environnement
		System.getProperties().put("BootStrap.location", location);
		System.getProperties().put("BootStrap.home", home.getPath());
		// invoke main method
		BootStrap.invokeMain(classloader, mainClassName, Command.argumentToArray(arguments), home);
		// stop
		Console.INFO("Exit");
		System.exit(0);
	}

	/**
	 * Initialize console (log and level).
	 * 
	 * @param arguments
	 */
	private static void initializeConsole(final Map<String, String> arguments) {
		// Initialize console
		final String logfileName = Command.getStringArgument(arguments, "BootStrap.logFile", null);
		if (logfileName != null) {
			Console.setLogFile(new File(logfileName));
		}
		Console.setInfo(Command.getBooleanArgument(arguments, "BootStrap.info", Boolean.TRUE));
		Console.setVerbose(Command.getBooleanArgument(arguments, "BootStrap.verbose", Boolean.FALSE));
	}

	/**
	 * Compute classpath.
	 * 
	 * @param home
	 * @param arguments
	 * @return a list of url to include in class path.
	 * @throws IllegalStateException
	 *             if an error occurs and should halt boot process.
	 */
	private static List<URL> computeClassPath(final File home, final Map<String, String> arguments)
			throws IllegalStateException {

		final Boolean includeJavaHomeLib = Command.getBooleanArgument(arguments, "BootStrap.includeJavaHomeLib",
				Boolean.FALSE);
		final Boolean includePlugins = Command.getBooleanArgument(arguments, "BootStrap.includePlugins", Boolean.TRUE);
		final String javaHome = System.getProperty("java.home", null);

		final List<URL> urls = new ArrayList<URL>();
		// add java home
		if (includeJavaHomeLib) {
			try {
				if (javaHome != null) {
					urls.addAll(BootStrap.computeFromDirectory(new File(javaHome, "lib")));
				} else {
					Console.WARNING("Java Home property is not set");
				}
			} catch (final MalformedURLException ex) {
				Console.WARNING("error when including JavaHomeLib :" + ex.getMessage());
				if (BootStrap.haltOnError) {
					throw new IllegalStateException();
				}
			}
		}
		// add ${HOME}/lib
		try {
			urls.addAll(BootStrap.computeFromDirectory(new File(home, "lib")));
		} catch (final MalformedURLException ex) {
			Console.WARNING("error when including './lib' :" + ex.getMessage());
			if (BootStrap.haltOnError) {
				throw new IllegalStateException();
			}
		}
		// add ${HOME}/plugins
		if (includePlugins) {
			try {
				final File plugins = new File(home, "plugins");
				if (plugins.exists()) {
					urls.add(plugins.toURI().toURL());
				}
			} catch (final MalformedURLException ex) {
				Console.WARNING("error when including './plugins' :" + ex.getMessage());
				if (BootStrap.haltOnError) {
					throw new IllegalStateException();
				}
			}
		}
		Console.VERBOSE("ClassPath: " + urls.toString());
		return urls;
	}

	/**
	 * Invoke main class inside the specific class loader.
	 * 
	 * @param classloader
	 *            class loader to use
	 * @param mainClassName
	 *            main class name
	 * @param arguments
	 *            arguments
	 * @param home
	 *            home directory
	 * @throws SecurityException
	 */
	private static void invokeMain(final ClassLoader classloader, final String mainClassName, final String[] arguments,
			final File home) throws SecurityException {
		// load main class
		Class<?> mainClass = null;
		try {
			mainClass = classloader.loadClass(mainClassName);
		} catch (final ClassNotFoundException ex) {
			Console.WARNING("class '" + mainClassName + "' not found: " + ex.getMessage());
		}
		if (mainClass != null) {
			Method main = null;
			try {
				// args.getClass()
				main = mainClass.getMethod("main", new Class<?>[] { String[].class });
			} catch (final NoSuchMethodException ex) {
				Console.WARNING("class '" + mainClassName + "' did not have a method 'main': " + ex.getMessage());
			} catch (final SecurityException ex) {
				Console.WARNING("class '" + mainClassName + "', can not access to 'main' method  : " + ex.getMessage());
			}
			if (main != null) {
				main.setAccessible(true);
				Console.VERBOSE("Entering main");
				try {
					final Object o = main.invoke(null, new Object[] { arguments });
					if (o != null) {
						Console.VERBOSE("READ '" + o + "'");
						// severe hacking way in order to perform a clean up after calling
						if ("clean".equals(o)) {
							Extract.cleanUp(home);
						}
					}
					// here we can add a reboot option
				} catch (final IllegalAccessException ex) {
					// should not occurs
					Console.WARNING("class '" + mainClassName + "', can not access to 'main' method  : "
							+ ex.getMessage());
				} catch (final IllegalArgumentException ex) {
					Console.WARNING("class '" + mainClassName + "', problem with argument of 'main' method  : "
							+ ex.getMessage());
				} catch (final InvocationTargetException ex) {
					Console.WARNING("class '" + mainClassName + "', exception occur when invoking 'main' method  : "
							+ ex.getMessage());
				}
			} else {
				Console.WARNING("The main() method in class '" + mainClassName + "' not found.");
			}
		}
	}

	/**
	 * Compute a list of jar file found in specified path in a recursive way.
	 * 
	 * @param path
	 *            directory path
	 * @return a list of URL found in specified directory.
	 * @throws MalformedURLException
	 */
	private static List<URL> computeFromDirectory(final File directory) throws MalformedURLException {
		final List<URL> urls = new ArrayList<URL>();
		if (directory.exists() && directory.isDirectory()) {
			for (final File child : directory.listFiles()) {
				if (child.isDirectory()) {
					urls.addAll(BootStrap.computeFromDirectory(new File(child.getPath())));
				} else if (child.getName().endsWith(".jar")) {
					urls.add(child.toURI().toURL());
				}
			}
		}
		return urls;
	}

	/**
	 * @return the code source location.
	 */
	private static String getCodeSourceLocation() {
		String root = null;
		final ProtectionDomain protectionDomain = BootStrap.class.getProtectionDomain();
		final CodeSource codeSource = protectionDomain.getCodeSource();
		final URL rootUrl = codeSource.getLocation();
		try {
			root = URLDecoder.decode(rootUrl.getFile(), "UTF-8");
		} catch (final UnsupportedEncodingException ex) {
			Console.WARNING("Unable to decode '" + rootUrl.getFile() + "' " + ex.getMessage(), ex);
		}
		return root;
	}

	/**
	 * Utility to close.
	 * 
	 * @param closeable
	 */
	private static void close(final Closeable closeable) {
		if (closeable != null) {
			try {
				closeable.close();
			} catch (final IOException exception) {
				Console.VERBOSE("Closing Error", exception);
			}
		}
	}

	/**
	 * Utility to delete file (directory or single file)
	 * 
	 * @param from
	 * @return
	 */
	private static boolean delete(final File from) {
		if ((from != null) && from.exists()) {
			if (from.isDirectory()) {
				for (final File child : from.listFiles()) {
					BootStrap.delete(child);
				}
			}
			return from.delete();
		}
		return false;
	}

	/**
	 * This class restart whole system and clean up 'tools' directory.
	 * 
	 * Restarter class offer common way for restarting a target, and finalize update process.
	 * 
	 * @author <a href="mailto:jguibert@intelligents-ia.com" >Jerome Guibert</a>
	 */
	private static class Restarter {

		public static void restartAfterUpdate(final Map<String, String> arguments) {

			Console.VERBOSE("(Restarter/main " + arguments + ")");
			if (!arguments.containsKey("home") || !arguments.containsKey("target")) {
				Console.WARNING("Called with " + arguments);
				Console.WARNING("Usage: \n Restarter --home=home-directory --target=jar-to-restart [--timeout=ms] ");
				return;
			}
			// load parameters
			final File home = new File(arguments.get("home"));
			final String target = arguments.get("target");
			final Integer timout = Command.getIntegerArgument(arguments, "timeout", 5 * 1000);
			// log file ?
			if (!Command.getBooleanArgument(arguments, "nolog", Boolean.FALSE)) {
				Console.setLogFile(new File(home, "restart.log"));
			}
			// call
			try {
				Restarter.processAfterUpdate(home, target, timout);
			} catch (final Throwable ex) {
				Console.WARNING("Error in (Restarter/processAfterUpdate " + home.getName() + " " + target + " "
						+ timout + ") => " + ex.getMessage(), ex);
			}
			// restarting
			try {
				Console.VERBOSE("Starting: " + target);
				Command.run("-jar", target);
			} catch (final IOException ex) {
				Console.WARNING("Error when starting " + target, ex);
			} catch (final InterruptedException ex) {
				Console.WARNING("Error when starting " + target, ex);
			}
			try {
				Thread.sleep(1000);
			} catch (final InterruptedException ex) {
			}
		}

		/**
		 * process stage after update: move file from 'update' folder to home, and restart the 'waitingTargetFile'.
		 * 
		 * @param home
		 *            root directory for find 'update' and install files
		 * @param waitingTargetFile
		 *            target file to launch at end
		 * @param timeout
		 *            (ms)if waitingTargetFile exists, time out for waiting access on this file.
		 * @throws IOException
		 * @throws InterruptedException
		 */
		public static void processAfterUpdate(final File home, final String waitingTargetFile, final int timeout)
				throws IOException, InterruptedException {
			assert timeout > 0;

			// waits while waitingTargetFile exits and cannot be overwritten
			int waiting = 0;
			while (new File(waitingTargetFile).exists() && !new File(waitingTargetFile).canWrite()
					&& (waiting < timeout)) {
				Console.VERBOSE("Wait for " + waitingTargetFile + " terminating");
				try {
					Thread.sleep(500);
				} catch (final InterruptedException e) {
					e.printStackTrace(System.err);
				}
				waiting += 500;
			}
			// try updating file
			final File update = new File(home, "update");
			if (update.exists()) {
				Console.VERBOSE("Updating");
				for (final File file : update.listFiles()) {
					Console.VERBOSE("    " + file.getName());
					final boolean success = file.renameTo(new File(home, file.getName()));
					if (!success) {
						Console.WARNING(" File was not successfully moved : " + file.getName());
					}
				}
				BootStrap.delete(update);
			} else {
				Console.VERBOSE("Update folder did not exists");
			}
		}
	}

	/**
	 * This class group all mechanisme for extract clean up system.
	 */
	private static class Extract {

		private static Boolean	cleanUpLib		= Boolean.TRUE;
		private static Boolean	cleanUpPlugins	= Boolean.TRUE;
		private static Boolean	cleanUpTools	= Boolean.FALSE;

		/**
		 * Explode Step.
		 * 
		 * @param location
		 *            inner jar location
		 * @param home
		 *            home directory to explode
		 * @return false if process should stop, true otherwise.
		 */
		public static boolean explode(final String location, final File home) {
			Extract.cleanUp(home);
			// Explode inner jar
			try {
				if (location != null) {
					Extract.explode(new File(home, "lib"), "lib/", location, Boolean.FALSE);
					Extract.explode(new File(home, "plugins"), "plugins/", location, Boolean.FALSE);
					// override inner updater tool
					Extract.explode(new File(home, "tools"), "tools/", location, Boolean.TRUE);
				}
			} catch (final Throwable t) {
				Console.WARNING("Error when exploding : " + t.getMessage(), t);
				if (BootStrap.haltOnError) {
					return false;
				}
			}
			return true;
		}

		/**
		 * Utility to clean up home.
		 * 
		 * @param home
		 */
		public static void cleanUp(final File home) {
			if (Extract.cleanUpLib) {
				Console.VERBOSE("Clean up lib");
				BootStrap.delete(new File(home, "lib"));
			}
			if (Extract.cleanUpPlugins) {
				Console.VERBOSE("Clean up plugins");
				BootStrap.delete(new File(home, "plugins"));
			}
			if (Extract.cleanUpTools) {
				Console.VERBOSE("Clean up tools");
				BootStrap.delete(new File(home, "tools"));
			}
		}

		/**
		 * Explode specified jar into home.
		 * 
		 * @param home
		 * @param fromPath
		 * @param jarPath
		 * @throws IOException
		 * @throws ZipException
		 */
		private static void explode(final File home, final String fromPath, final String jarPath, final Boolean override)
				throws IOException, ZipException {
			final JarFile jar = new JarFile(URLDecoder.decode(jarPath, "UTF-8"));
			for (final JarEntry entry : Collections.list(jar.entries())) {
				final String name = entry.getName();
				if (name.startsWith(fromPath)) {
					final String targetName = name.substring(fromPath.length()).trim();
					if (!"".equals(targetName)) // not empty
					{
						final File localFile = new File(home, targetName);
						if (override && localFile.exists()) {
							localFile.delete();
						}
						if (!localFile.exists()) {
							Extract.explodeEntry(targetName, localFile, jar, name);
						}
					}
				}
			}
		}

		/**
		 * Extract a specific entry.
		 * 
		 * @param targetName
		 * @param localFile
		 * @param jar
		 * @param name
		 * @throws IOException
		 */
		private static void explodeEntry(final String targetName, final File localFile, final JarFile jar,
				final String name) throws IOException {
			// create a directory
			if (targetName.endsWith("/")) {
				if (!localFile.mkdirs()) {
					if (BootStrap.haltOnError) {
						throw new IOException("Unable to create directory '" + localFile.getPath() + "'");
					}
				}
			} else {
				// create local file if not exits
				if (!localFile.exists()) {
					Boolean errorOccurs = Boolean.FALSE;
					final File parent = localFile.getParentFile();
					if ((parent != null) && !parent.exists()) {
						if (!parent.mkdirs()) {
							if (BootStrap.haltOnError) {
								throw new IOException("Parent File Of '" + localFile.getPath()
										+ "' could not be created");
							} else {
								errorOccurs = Boolean.TRUE;
							}
						}
					}
					if (!errorOccurs && !localFile.createNewFile()) {
						if (BootStrap.haltOnError) {
							throw new IOException("Unable to create file " + localFile.getPath());
						} else {
							errorOccurs = Boolean.TRUE;
						}
					}
					if (!errorOccurs) {
						InputStream inputStream = null;
						OutputStream outputStream = null;
						try {
							inputStream = jar.getInputStream(jar.getEntry(name));
							if (inputStream == null) {
								if (BootStrap.haltOnError) {
									throw new IOException("Unable to access resource '" + name + "'");
								}
							}
							outputStream = new FileOutputStream(localFile);
							final byte[] buf = new byte[4096 * 4];
							int len = 0;
							while ((len = inputStream.read(buf)) > 0) {
								outputStream.write(buf, 0, len);
							}
						} catch (final IOException exception) {
							Console.VERBOSE("Error when exploding Entry: " + name, exception);
							BootStrap.delete(localFile);
						} finally {
							BootStrap.close(inputStream);
							BootStrap.close(outputStream);
						}
					}
				}
			}
		}

		/**
		 * Initialize clean up directive.
		 * 
		 * @param arguments
		 */
		public static void initialize(final Map<String, String> arguments) {
			Extract.cleanUpTools = Command.getBooleanArgument(arguments, "BootStrap.cleanUpTools", Boolean.FALSE);
			Extract.cleanUpLib = Command.getBooleanArgument(arguments, "BootStrap.cleanUpLib", Boolean.TRUE);
			Extract.cleanUpPlugins = Command.getBooleanArgument(arguments, "BootStrap.cleanUpPlugins", Boolean.FALSE);
		}
	}
}
